/* Vulnerability auditor for npm
 *
 * Inputs:
 *  - path to directory containing a package.json and a package-lock.json
 *  - array of security advisories to audit for,
 *      [
 *        { dependency_name: string, affected_versions: [string, ...] },
 *        ...
 *      ]
 *
 * Outputs:
 *  - object indicating whether a fix is available and how to achieve it,
 *      {
 *        dependency_name: string,
 *        current_version: string,
 *        target_version: string,
 *        fix_available: boolean | object,
 *        fix_updates: [
 *          {
 *            dependency_name: string,
 *            current_version: string,
 *            target_version: string
 *          },
 *          ...
 *        ]
 *      }
 */

const Arborist = require('@npmcli/arborist')
const nock = require('nock')
const { promisify } = require('util');
const exec = promisify(require('child_process').exec)

async function findVulnerableDependencies(directory, advisories) {
  const npmConfig = await loadNpmConfig()
  const caCerts = loadCACerts(npmConfig)
  const registryOpts = extractRegistryOptions(npmConfig)
  const registryCreds = loadNpmConfigCredentials(directory)

  const arb = new Arborist({
    path: directory,
    auditRegistry: 'http://localhost:9999',
    ca: caCerts,
    force: true,
    dryRun: true,
    ignoreScripts: true,
    ...registryOpts,
    ...registryCreds,
  })

  const scope = nock('http://localhost:9999')
    .persist()
    .post('/-/npm/v1/security/advisories/bulk')
    .reply(200, convertAdvisoriesToRegistryBulkFormat(advisories))

  if (!nock.isActive()) {
    nock.activate()
  }

  try {
    const name = advisories[0].dependency_name
    const response = {
      dependency_name: name,
      fix_updates: [],
      top_level_ancestors: [],
    }
    const auditReport = await arb.audit()
    if (!auditReport.has(name)) {
      if (auditReport.tree.children.has(name)) {
        response.current_version = auditReport.tree.children.get(name).version
      }
      response.fix_available = false
      return response
    }
    const vuln = auditReport.get(name)
    const version = [...vuln.nodes][0].version
    const fixAvailable = vuln.fixAvailable

    response.current_version = version
    response.fix_available = Boolean(fixAvailable)

    if (!Boolean(fixAvailable)) {
      return response
    }

    const chains = buildDependencyChains(auditReport, name)

    // In order for the vuln dependency in question to be considered fixable,
    // all dependency chains originating from it must be fixable.
    if (chains.some((chain) => !Boolean(chain.fixAvailable))) {
      response.fix_available = false
      return response
    }

    const groupedFixUpdateChains = groupBy(chains, (chain) => chain.nodes[0].pkgid)
    let topLevelAncestors = new Set()

    for (const group of groupedFixUpdateChains.values()) {
      const fixUpdateNode = group[0].nodes[0]
      const groupTopLevelAncestors = group.reduce((ancestor, chain) => {
        const topLevelNode = chain.nodes[chain.nodes.length - 1]
        return ancestor.add(topLevelNode.name)
      }, new Set())

      // Add group's top-level ancestors to the set of all top-level ancestors of
      // the vuln dependency in question.
      topLevelAncestors = new Set([...topLevelAncestors, ...groupTopLevelAncestors])

      // If a chain consists of only one node, chain.nodes[0].name == chain.nodes[chain.nodes.length-1].name.
      // In such cases, don't include the fix update node as an ancestor of itself.
      const fixUpdateNodeTopLevelAncestors =
        [...groupTopLevelAncestors].filter((nodeName) => nodeName !== fixUpdateNode.name).sort()

      response.fix_updates.push({
        dependency_name: fixUpdateNode.name,
        current_version: fixUpdateNode.version,
        top_level_ancestors: fixUpdateNodeTopLevelAncestors,
      })
    }

    response.top_level_ancestors = [...topLevelAncestors].sort()

    const fixTree = await arb.audit({
      fix: true,
    })

    response.target_version = fixTree.children.get(name)?.version

    for (const update of response.fix_updates) {
      update.target_version =
        fixTree.children.get(update.dependency_name)?.version
    }

    return response
  } finally {
    nock.cleanAll()
    nock.restore()
  }
}

function convertAdvisoriesToRegistryBulkFormat(advisories) {
  return advisories.reduce((formattedAdvisories, advisory) => {
    if (!formattedAdvisories[advisory.dependency_name]) {
      formattedAdvisories[advisory.dependency_name] = []
    }
    let formattedVersions =
      advisory.affected_versions.reduce((memo, version) => {
        memo.push({
          id: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER),
          vulnerable_versions: version
        })
        return memo
      }, [])
    formattedAdvisories[advisory.dependency_name].push(...formattedVersions)
    return formattedAdvisories
  }, {})
}

/* Returns an array of dependency chains rooted in the named dependency,
 *   [
 *    {
 *      fixAvailable: true | false | object,
 *      nodes: [
 *        ArboristNode {
 *          name: 'foo',
 *          version: '1.0.0',
 *          ...
 *        },
 *        ...
 *      ]
 *    },
 *    ...
 *   ]
 *
 * The first node in each chain is the innermost dependency affected by the vuln;
 * the `fixAvailable` field applies to this dependency. The last node in each
 * chain is always a top-level dependency.
 */
function buildDependencyChains(auditReport, name) {
  const helper = (node, chain, visited) => {
    if (!node) {
      return []
    }
    if (visited.has(node.name)) {
      // We've already seen this node; end path.
      return []
    }
    if (auditReport.has(node.name)) {
      const vuln = auditReport.get(node.name)
      if (vuln.isVulnerable(node)) {
        return [{ fixAvailable: vuln.fixAvailable, nodes: [node, ...chain.nodes] }]
      } else if (node.name == name) {
        // This is a non-vulnerable version of the advisory dependency; end path.
        return []
      }
    }
    if (!node.edgesOut.size) {
      // This is a leaf node that is unaffected by the vuln; end path.
      return []
    }
    return [...node.edgesOut.values()].reduce((chains, { to }) => {
      // Only prepend current node to chain/visited if it's not the project root.
      const newChain = node.isProjectRoot ? chain : { nodes: [node, ...chain.nodes] }
      const newVisited = node.isProjectRoot ? visited : new Set([node.name, ...visited])
      return chains.concat(helper(to, newChain, newVisited))
    }, [])
  }
  return helper(auditReport.tree, { nodes: [] }, new Set())
}

function groupBy(elems, fn) {
  const groups = new Map()
  for (const [index, elem] of [...elems].entries()) {
    const key = fn(elem, index, elems)
    groups.set(key, (groups.get(key) || []).concat([elem]))
  }
  return groups
}

async function loadNpmConfig() {
  const configOutput = await exec('npm config ls --json')
  return JSON.parse(configOutput.stdout)
}

function extractRegistryOptions(npmConfig) {
  const opts = []
  for (const [key, value] of Object.entries(npmConfig)) {
    if (key == "registry" || key.endsWith(":registry")) {
      opts.push([key, value])
    }
  }
  return Object.fromEntries(opts)
}

// loadNpmConfig doesn't return registry credentials so we need to manually extract them. If available,
// Dependabot will have written them to the project's .npmrc file.
const ini = require('ini')
const path = require('path')

const credKeys = ['token', '_authToken', '_auth']

function loadNpmConfigCredentials(projectDir) {
  const projectNpmrc = maybeReadFile(path.join(projectDir, '.npmrc'))
  if (!projectNpmrc) {
    return {}
  }

  const credentials = []
  const config = ini.parse(projectNpmrc)
  for (const [key, value] of Object.entries(config)) {
    if (credKeys.includes(key) || credKeys.some((credKey) => key.endsWith(':' + credKey))) {
      credentials.push([key, value])
    }
  }
  return Object.fromEntries(credentials)
}

// sourced from npm's cli/lib/utils/config/definitions.js for reading certs from the cafile option
const fs = require('fs')
const maybeReadFile = file => {
  try {
    return fs.readFileSync(file, 'utf8')
  } catch (er) {
    if (er.code !== 'ENOENT') {
      throw er
    }
    return null
  }
}

function loadCACerts(npmConfig) {
  if (npmConfig.ca) {
    return npmConfig.ca
  }

  if (!npmConfig.cafile) {
    return
  }

  const raw = maybeReadFile(npmConfig.cafile)
  if (!raw) {
    return
  }

  const delim = '-----END CERTIFICATE-----'
  return raw.replace(/\r\n/g, '\n').split(delim)
    .filter(section => section.trim())
    .map(section => section.trimStart() + delim)
}

module.exports = { findVulnerableDependencies }
